Loopar Introduction
loopar-webpage

API Reference

Complete reference for Loopar's server-side APIs.


Core Objects

ObjectDescription
looparGlobal framework object
BaseDocumentBase class for Models
BaseControllerBase class for Controllers

Import Patterns

// In Model (.js)
import { BaseDocument } from "loopar";
import loopar from "loopar";

// In Controller (-controller.js)
import { BaseController } from "loopar";
import loopar from "loopar";

// In any server file
import loopar from "loopar";

Quick Reference

Document Operations

const doc = await loopar.getDocument("Entity", "name");
const doc = await loopar.newDocument("Entity");
await doc.save();
await doc.delete();

Database Queries

const all = await loopar.db.getAll("Entity", fields, filter);
const count = await loopar.db.count("Entity", filters);
const value = await loopar.db.getValue("Entity", "name", "field");

Utilities

const user = loopar.session.user;
const settings = await loopar.getSettings();
await loopar.mail.send({ to, subject, html });

loopar (Global Object)

The main framework object available throughout the server-side code.

import loopar from "loopar";

Document Methods

loopar.getDocument(entity, name)

Retrieve an existing document by name.

const customer = await loopar.getDocument("Customer", "CUST-0001");

// Access fields
console.log(customer.email);
console.log(customer.status);

// Call model methods
const fullName = customer.getFullName();
ParameterTypeDescription
entitystringEntity name
namestringDocument name/ID

Returns: Document instance

Throws: Error if document not found


loopar.newDocument(entity, data?)

Create a new document instance.

// Create empty
const customer = await loopar.newDocument("Customer");
customer.customer_name = "John Doe";
customer.email = "john@example.com";
await customer.save();

// Create with initial data
const task = await loopar.newDocument("Task", {
title: "New Task",
status: "Open",
priority: "High"
});
await task.save();
ParameterTypeDescription
entitystringEntity name
dataobjectInitial field values (optional)

Returns: New document instance (unsaved)


loopar.deleteDocument(entity, name)

Delete a document by name.

await loopar.deleteDocument("Customer", "CUST-0001");
ParameterTypeDescription
entitystringEntity name
namestringDocument name/ID

Session & User

loopar.session

Current session information.

// Current user
const user = loopar.session.user;
const userName = loopar.session.user_name;

// Check if logged in
if (loopar.session.user) {
// User is authenticated
}

// Current site/tenant
const site = loopar.session.site;
PropertyTypeDescription
userstringCurrent user ID
user_namestringUser display name
sitestringCurrent tenant name
is_adminbooleanIs administrator

Configuration

loopar.getSettings()

Access system settings (cached after the first call).

// System settings document
const settings = await loopar.getSettings();

// Database connection config
const dbConfig = loopar.getDbConfig();

Utilities

loopar.mail.send(options)

Send an email through the built-in email service (loopar.mail).

await loopar.mail.send({
to: "customer@example.com",
subject: "Welcome!",
html: "<h1>Welcome to our platform</h1>",
// Optional
from: "noreply@yoursite.com",
cc: ["manager@yoursite.com"],
attachments: [{ filename: "doc.pdf", path: "/path/to/doc.pdf" }]
});
OptionTypeDescription
tostring/arrayRecipient(s)
subjectstringEmail subject
htmlstringHTML body
fromstringSender (optional)
ccarrayCC recipients
bccarrayBCC recipients
attachmentsarrayFile attachments

loopar.throw(message, httpCode?)

Throw an error with optional HTTP status code.

if (!customer) {
loopar.throw("Customer not found", 404);
}

if (!hasPermission) {
loopar.throw("Access denied", 403);
}

loopar.log(message, level?)

Log a message.

loopar.log("Processing started");
loopar.log("Warning: slow query", "warn");
loopar.log("Error occurred", "error");
LevelDescription
infoInformation (default)
warnWarning
errorError
debugDebug (dev only)

loopar.db (Database API)

loopar.db is the framework's data layer — a Knex-powered query engine that works across MySQL/MariaDB, PostgreSQL, SQLite, SQL Server and Oracle.

import loopar from "loopar";

const results = await loopar.db.getAll("Customer");

Query Methods

loopar.db.getList(document, fields?, filter?, options?)

Get a paginated list of records.

// All fields, current page
const page = await loopar.db.getList("Customer");

// Selected fields + filter
const active = await loopar.db.getList(
"Customer",
["name", "email", "status"],
{ status: "Active" }
);

Pagination is controlled by loopar.db.pagination and loopar.db.setPage(n).


loopar.db.getAll(document, fields?, filter?)

Same as getList, but returns every matching row (no pagination).

const customers = await loopar.db.getAll(
"Customer",
["name", "email"],
{ status: "Active" }
);
ParameterTypeDescription
documentstringEntity name
fieldsarrayFields to return (default ["*"])
filterobjectWHERE conditions

loopar.db.getRow(document, filter)

Get a single raw row matching a filter, or null.

const row = await loopar.db.getRow("Customer", { email: "john@example.com" });

loopar.db.getValue(document, name, field)

Get one field value from a document.

const email = await loopar.db.getValue("Customer", "CUST-0001", "email");

loopar.db.count(document, filter)

Count records matching a filter.

const active = await loopar.db.count("Customer", { status: "Active" });

loopar.db.hasEntity(constructor, document) / loopar.db.hasTable(name)

Check whether an entity record or a physical table exists.

const exists = await loopar.db.hasTable("Customer");

Filter Conditions

Filters are plain objects. For operators, import Op from loopar:

import loopar, { Op } from "loopar";

// Exact match (AND between keys)
{ status: "Active", priority: "High" }

// Operators
{ amount: { [Op.gt]: 1000 } }
{ created_at: { [Op.gte]: "2024-01-01" } }
{ name: { [Op.like]: "%john%" } }
{ status: { [Op.in]: ["Open", "In Progress"] } }

Modify Methods

loopar.db.setValue(document, name, field, value)

Update a single field directly.

await loopar.db.setValue("Customer", "CUST-0001", "status", "VIP");

Note: This bypasses model hooks. Use doc.save() for the full lifecycle.


loopar.db.insertRow / updateRow / deleteRow / deleteWhere

Low-level row operations used by the document layer. In application code, prefer doc.save() and doc.delete().

await loopar.db.deleteWhere("Log", { created_at: { [Op.lt]: "2024-01-01" } });

Raw SQL

loopar.db.raw(sql, replacements?)

Run a parameterized raw query.

const stats = await loopar.db.raw(`
SELECT project, COUNT(*) AS task_count
FROM Task
WHERE created_at > ?
GROUP BY project
`, ["2024-01-01"]);

Warning: Always use ? placeholders with the replacements array to prevent SQL injection.


Transactions

await loopar.db.beginTransaction();
try {
const order = await loopar.newDocument("Order", { customer: "CUST-0001" });
await order.save();
await loopar.db.endTransaction();   // commit
} catch (e) {
await loopar.db.safeRollback();
throw e;
}
MethodDescription
beginTransaction()Start a transaction
endTransaction()Commit the transaction
safeRollback()Roll back safely

BaseDocument

Base class for all Models. Extend this to add custom logic to your entities.

// customer.js (MODEL)
import { BaseDocument } from "loopar";

export default class Customer extends BaseDocument {
constructor(props) {
super(props);
}
}

Instance Properties

Field Values

Access and set field values directly.

// Read fields
const name = this.customer_name;
const email = this.email;
const status = this.status;

// Set fields
this.status = "Active";
this.modified_at = new Date();
this.total = this.subtotal + this.tax;

this.name

The document's unique identifier.

console.log(this.name); // "CUST-0001"

this.isNew

Whether this is a new (unsaved) document.

async beforeSave() {
if (this.isNew) {
this.created_at = new Date();
this.created_by = this.session.user;
}
}

this.session

Current session information.

const currentUser = this.session.user;
const currentSite = this.session.site;

this.request

HTTP request object (when triggered via API).

const body = this.request?.body;
const query = this.request?.query;
const headers = this.request?.headers;

Instance Methods

this.save(options?)

Save the document to database.

// Simple save
await this.save();

// Save with options
await this.save({
ignore_permissions: true,  // Skip permission check
ignore_validate: false,    // Skip validation
trx: transaction           // Use transaction
});

Triggers: validate()beforeSave()beforeInsert()/beforeUpdate() → DB → afterInsert()/afterUpdate()afterSave()


this.delete()

Delete the document.

await this.delete();

Triggers: beforeDelete() → DB → afterDelete()


this.reload()

Reload document from database.

await this.reload();

this.getData()

Get document as plain object.

const data = this.getData();
// { name: "CUST-0001", customer_name: "John", email: "john@...", ... }

Lifecycle Hooks

Override these methods to add custom logic.

async validate()

Validate before any save operation.

async validate() {
if (!this.email) {
throw new Error("Email is required");
}

if (this.amount < 0) {
throw new Error("Amount cannot be negative");
}

// Check for duplicates
const existing = await loopar.db.getRow("Customer", {
email: this.email,
name: ["!=", this.name]  // Exclude self
});

if (existing) {
throw new Error("Email already exists");
}
}

async beforeInsert()

Before saving a NEW document.

async beforeInsert() {
this.created_at = new Date();
this.created_by = this.session.user;
this.status = this.status || "Draft";

// Generate custom ID
this.customer_id = await this.generateCustomerId();
}

async afterInsert()

After saving a NEW document.

async afterInsert() {
// Send welcome email
await loopar.mail.send({
to: this.email,
subject: "Welcome!",
html: `Hello ${this.customer_name}!`
});

// Create related records
const settings = await loopar.newDocument("Customer Settings");
settings.customer = this.name;
await settings.save();
}

async beforeUpdate()

Before updating an EXISTING document.

async beforeUpdate() {
this.modified_at = new Date();
this.modified_by = this.session.user;
}

async afterUpdate()

After updating an EXISTING document.

async afterUpdate() {
// Log changes
await this.logChanges();

// Notify if status changed
if (this.status !== this._previousStatus) {
await this.notifyStatusChange();
}
}

async beforeSave()

Before any save (insert OR update).

async beforeSave() {
// Calculate totals
this.total = this.subtotal + this.tax - this.discount;

// Update full name
this.full_name = `${this.first_name} ${this.last_name}`;
}

async afterSave()

After any save (insert OR update).

async afterSave() {
// Update related records
await this.updateRelatedRecords();

// Clear cache
await this.clearCache();
}

async beforeDelete()

Before deleting.

async beforeDelete() {
// Prevent deletion of locked records
if (this.is_locked) {
throw new Error("Cannot delete locked record");
}

// Check for dependencies
const orders = await loopar.db.count("Order", { customer: this.name });
if (orders > 0) {
throw new Error("Cannot delete customer with orders");
}
}

async afterDelete()

After deleting.

async afterDelete() {
// Clean up related data
await loopar.db.raw(
"DELETE FROM CustomerSettings WHERE customer = ?",
[this.name]
);

// Log deletion
console.log(`Customer ${this.name} deleted`);
}

BaseController

Base class for Controllers. Only methods prefixed with action are accessible via URL.

// customer-controller.js (CONTROLLER)
import { BaseController } from "loopar";

export default class CustomerController extends BaseController {

// GET/POST /api/Customer/stats
async actionStats() {
return { total: 100 };
}
}

URL Routing

Method NameURLHTTP Methods
actionView/api/Entity/viewGET, POST
actionCreate/api/Entity/createPOST
actionStats/api/Entity/statsGET, POST
actionSendEmail/api/Entity/send-emailGET, POST
privateMethodNOT accessible

Naming: actionMyAction/api/Entity/my-action (camelCase to kebab-case)


Instance Properties

this.data

Request data (body + query params).

async actionCreate() {
const { name, email, status } = this.data;

const customer = await loopar.newDocument("Customer", {
customer_name: name,
email,
status
});
await customer.save();

return { success: true, name: customer.name };
}

this.request

Full HTTP request object.

async actionUpload() {
const file = this.request.files?.document;
const contentType = this.request.headers["content-type"];
const method = this.request.method;

// ...
}
PropertyDescription
methodHTTP method (GET, POST, etc.)
headersRequest headers
queryQuery string params
bodyRequest body
filesUploaded files
paramsURL params

this.response

HTTP response object.

async actionDownload() {
this.response.setHeader("Content-Type", "application/pdf");
this.response.setHeader("Content-Disposition", "attachment; filename=report.pdf");

// Return file buffer
return fileBuffer;
}

this.session

Current session.

async actionProfile() {
const user = this.session.user;

if (!user) {
loopar.throw("Not authenticated", 401);
}

return await loopar.getDocument("User", user);
}

Action Examples

Basic CRUD Action

// POST /api/Customer/create
async actionCreate() {
const customer = await loopar.newDocument("Customer", this.data);
await customer.save();

return {
success: true,
message: "Customer created",
name: customer.name
};
}

Query Action

// GET /api/Customer/search?q=john&status=Active
async actionSearch() {
const { q, status, limit = 20 } = this.data;

const filters = {};
if (status) filters.status = status;
if (q) filters.customer_name = ["like", `%${q}%`];

const customers = await loopar.db.getAll("Customer", ["*"], filters);

return { customers };
}

Action with Document

// POST /api/Customer/upgrade-to-vip
async actionUpgradeToVip() {
const { name } = this.data;

if (!name) {
loopar.throw("Customer name required", 400);
}

const customer = await loopar.getDocument("Customer", name);
customer.status = "VIP";
customer.vip_since = new Date();
await customer.save();

// Call model method
const discount = customer.calculateVIPDiscount();

return {
success: true,
message: `${customer.customer_name} is now VIP`,
discount
};
}

Stats/Aggregation Action

// GET /api/Task/dashboard
async actionDashboard() {
const user = this.session.user;

const [open, inProgress, completed, overdue] = await Promise.all([
loopar.db.count("Task", { status: "Open", assigned_to: user }),
loopar.db.count("Task", { status: "In Progress", assigned_to: user }),
loopar.db.count("Task", { status: "Completed", assigned_to: user }),
loopar.db.raw(
`SELECT COUNT(*) as count FROM Task 
WHERE assigned_to = ? AND due_date < NOW() AND status != 'Completed'`,
[user]
).then(r => r[0]?.count || 0)
]);

return {
open,
inProgress,
completed,
overdue,
total: open + inProgress + completed
};
}

File Download Action

// GET /api/Report/export?format=csv
async actionExport() {
const { format = "csv" } = this.data;

const data = await loopar.db.getAll("Customer");

if (format === "csv") {
const csv = this.convertToCSV(data);
this.response.setHeader("Content-Type", "text/csv");
this.response.setHeader("Content-Disposition", "attachment; filename=customers.csv");
return csv;
}

return { data };
}

// Private helper (NOT URL accessible)
convertToCSV(data) {
// ...
}

Error Handling

async actionRiskyOperation() {
try {
// Risky operation
await this.performOperation();
return { success: true };

} catch (error) {
// Log error
loopar.log(error.message, "error");

// Return error response
loopar.throw(error.message, 500);
}
}

Authentication Check

async actionSecureAction() {
// Check authentication
if (!this.session.user) {
loopar.throw("Authentication required", 401);
}

// Check admin
if (!this.session.is_admin) {
loopar.throw("Admin access required", 403);
}

// Proceed with action
return { secret: "data" };
}

REST API

Loopar automatically generates REST endpoints for all entities.


Auto-Generated Endpoints

MethodEndpointDescription
GET/api/{Entity}List documents
GET/api/{Entity}/{name}Get single document
POST/api/{Entity}Create document
PUT/api/{Entity}/{name}Update document
DELETE/api/{Entity}/{name}Delete document

List Documents

GET /api/Customer

Query Parameters:

ParamDescriptionExample
pagePage number?page=2
limitRecords per page?limit=50
searchSearch term?search=john
order_bySort field?order_by=created_at
orderSort direction?order=desc
fieldsFields to return?fields=name,email
{field}Filter by field?status=Active

Example:

GET /api/Customer?status=Active&limit=20&order_by=customer_name&order=asc

Response:

{
"data": [
{ "name": "CUST-0001", "customer_name": "John", "email": "john@..." },
{ "name": "CUST-0002", "customer_name": "Jane", "email": "jane@..." }
],
"total": 150,
"page": 1,
"limit": 20,
"pages": 8
}

Get Single Document

GET /api/Customer/CUST-0001

Response:

{
"name": "CUST-0001",
"customer_name": "John Doe",
"email": "john@example.com",
"status": "Active",
"created_at": "2024-01-15T10:30:00Z"
}

Create Document

POST /api/Customer
Content-Type: application/json

{
"customer_name": "New Customer",
"email": "new@example.com",
"status": "Lead"
}

Response:

{
"success": true,
"name": "CUST-0003",
"message": "Document created"
}

Update Document

PUT /api/Customer/CUST-0001
Content-Type: application/json

{
"status": "VIP",
"credit_limit": 50000
}

Response:

{
"success": true,
"name": "CUST-0001",
"message": "Document updated"
}

Delete Document

DELETE /api/Customer/CUST-0001

Response:

{
"success": true,
"message": "Document deleted"
}

Custom Actions

Call controller actions:

# GET action
GET /api/Customer/stats

# POST action with data
POST /api/Customer/upgrade-to-vip
Content-Type: application/json

{
"name": "CUST-0001"
}

Error Responses

{
"error": true,
"message": "Customer not found",
"status": 404
}
StatusMeaning
400Bad request / Validation error
401Authentication required
403Permission denied
404Document not found
500Server error

Authentication

Include session token in requests:

GET /api/Customer
Authorization: Bearer {token}

Or use cookie-based session after login.

Client-Side API

React hooks and utilities for client-side code (.jsx files).


useDocument Hook

Access and manipulate the current document in a form.

import { useDocument } from "@loopar/components";

export default function CustomerForm(props) {
const { 
document,     // Current document data
setValue,     // Set field value
getValue,     // Get field value
save,         // Save document
loading,      // Loading state
errors        // Validation errors
} = useDocument();

return (
<div>

<h1>{document.customer_name}</h1>
<p>Status: {document.status}</p>

<button 
onClick={() => setValue("status", "VIP")}
disabled={loading}
>
Upgrade to VIP
</button>

<button onClick={save} disabled={loading}>
{loading ? "Saving..." : "Save"}
</button>

</div>
);
}

setValue(field, value)

Update a field value.

setValue("status", "Active");
setValue("amount", 1500.50);
setValue("items", [...document.items, newItem]);

getValue(field)

Get current field value.

const status = getValue("status");
const total = getValue("total");

save()

Save the document.

const handleSave = async () => {
try {
await save();
toast.success("Saved successfully!");
} catch (error) {
toast.error(error.message);
}
};

useLoopar Hook

Access Loopar utilities.

import { useLoopar } from "@loopar/components";

export default function MyComponent() {
const loopar = useLoopar();

const handleAction = async () => {
const result = await loopar.call({
entity: "Customer",
action: "stats"
});

console.log(result);
};

return <button onClick={handleAction}>Get Stats</button>;
}

loopar.call(options)

Call a server action.

// GET action
const stats = await loopar.call({
entity: "Customer",
action: "stats"
});

// POST action with data
const result = await loopar.call({
entity: "Customer",
action: "upgrade-to-vip",
method: "POST",
data: { name: "CUST-0001" }
});

loopar.navigate(path)

Navigate to a different page.

loopar.navigate("/desk/Customer/list");
loopar.navigate("/desk/Customer/edit/CUST-0001");

BaseForm Component

Wrapper for entity forms.

import { BaseForm } from "@loopar/components";

export default function CustomerForm(props) {
return (
<BaseForm {...props}>
{/* Custom content renders above default fields */}
<div className="custom-header">

{/* Custom UI */}

</div>

{/* Default fields render automatically */}
</BaseForm>
);
}

Utility Components

import { 
Button,
Input,
Select,
Badge,
Alert,
Card,
Table
} from "@loopar/components";

// Or from shadcn/ui
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";

Example: Custom Form UI

import { BaseForm, useDocument } from "@loopar/components";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { CheckCircle, AlertTriangle } from "lucide-react";

export default function TaskForm(props) {
const { document, setValue, save, loading } = useDocument();

const markComplete = async () => {
setValue("status", "Completed");
setValue("completed_at", new Date().toISOString());
await save();
};

const isOverdue = document.due_date && 
new Date(document.due_date) < new Date() && 
document.status !== "Completed";

return (
<BaseForm {...props}>
{/* Status Header */}
<div className="flex items-center justify-between p-4 bg-muted rounded-lg mb-6">

<div className="flex items-center gap-2">

<h2 className="font-semibold">{document.title || "New Task"}</h2>
<Badge variant={document.status === "Completed" ? "success" : "default"}>
{document.status}
</Badge>

</div>

{document.status !== "Completed" && (
<Button onClick={markComplete} disabled={loading}>
<CheckCircle className="w-4 h-4 mr-2" />
Mark Complete
</Button>
)}

</div>

{/* Overdue Warning */}
{isOverdue && (
<div className="p-3 mb-4 bg-destructive/10 text-destructive rounded-lg flex items-center gap-2">

<AlertTriangle className="w-4 h-4" />
This task is overdue!

</div>
)}
</BaseForm>
);
}